Skip to content

feat: replace homepage frameworks with fun rotating "n p m x" picks#1616

Open
serhalp wants to merge 14 commits intomainfrom
feat/hourly-npmx-picks
Open

feat: replace homepage frameworks with fun rotating "n p m x" picks#1616
serhalp wants to merge 14 commits intomainfrom
feat/hourly-npmx-picks

Conversation

@serhalp
Copy link
Member

@serhalp serhalp commented Feb 24, 2026

🔗 Linked issue

😶 discussed on Discord

🧭 Context

  • The hardcoded framework package quick list has an unclear purpose. More and more folks are requesting to add their link. It isn't a scalable feature.
  • The below is a subtle homage to the OG npmjs.com (they used to show a random "definition" of "npm" on page load).
  • It's a fun little easter egg that may lead curious users to wonder how the packages are selected... encouraging them to look at our repo, learn about our atproto features, and join our community 😈.
  • It's somewhat of a metaphor for npmx being borne out of the ecosystem.

📚 Description

Show 4 randomly-selected packages whose names contain the letters n, p, m, x, with the matching letter highlighted in bold + accent colour (one distinct colour per letter when no user accent is set).

Screenshot 2026-02-26 at 22 13 32 Screenshot 2026-02-26 at 22 14 52 Screenshot 2026-02-26 at 22 16 38

Selection algorithm:

  1. Fetch top 500 popular packages from the Algolia search index (empty query, default popularity ranking). Algolia doesn't support filtering by name substring (and doesn't know about our social likes), so we filter these results down after the fact (not deprecated, >=10k downloads/30d).
  2. For each letter (n, p, m, x):
    1. Take 30 random candidates whose name contains that letter and check their social like count.
    2. If there are candidates with >=5 community likes, keep only those; otherwise, keep all.
    3. Remove any remaining candidates already assigned to another letter in "n p m x".
    4. Randomly pick one remaining candidate.
    5. If there are no remaining candidates, pick the hardcoded default for this letter (nuxt, pnpm, module-replacements, oxfmt).

Results are cached for 1 hour via with SWR, so all users see the same picks for about an hour, and no user ever experiences a cache miss (and Algolia/constellation slowness).

Show 4 randomly-selected packages whose names contain the letters n, p, m, X, with the matching
letter highlighted in bold + accent colour (one distinct colour per letter when no user accent is
set).

Selection algorithm:
1. Fetch top 500 popular packages from the Algolia search index (empty query, default popularity
   ranking). Algolia doesn't support filtering by name substring (and doesn't know about our social
   likes), so we filter these results down after the fact (not deprecated, >=10k downloads/30d,
   modified <2yrs).
2. For each letter (n, p, m, x)
  1. Take 30 random candidates whose name contains that letter and check their social like count.
  2. If there are candidates with >=5 community likes, keep only those; otherwise, keep all.
  3. Randomly pick one remaining candidate.
  4. If there are no remaining candidates, pick the hardcoded default for this letter (nuxt, pnpm,
     module-replacements, oxfmt).

Results are cached for 1 hour via with SWR, so all users see the same picks for about an hour, and
no user ever experiences a cache miss (and Algolia/constellation slowness).
@vercel
Copy link

vercel bot commented Feb 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 27, 2026 9:43pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 27, 2026 9:43pm
npmx-lunaria Ignored Ignored Feb 27, 2026 9:43pm

Request Review

@serhalp serhalp changed the title feat: replace framework pkgs with random "n p m x" picks feat: replace homepage framework packages with fun rotating "n p m x" picks Feb 24, 2026
@serhalp serhalp changed the title feat: replace homepage framework packages with fun rotating "n p m x" picks feat: replace homepage frameworks with fun rotating "n p m x" picks Feb 24, 2026
@github-actions
Copy link

github-actions bot commented Feb 24, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/ar-EG.json Localization changed, will be marked as complete. 🔄️
lunaria/files/az-AZ.json Localization changed, will be marked as complete. 🔄️
lunaria/files/bg-BG.json Localization changed, will be marked as complete. 🔄️
lunaria/files/bn-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/cs-CZ.json Localization changed, will be marked as complete. 🔄️
lunaria/files/de-DE.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/es-419.json Localization changed, will be marked as complete. 🔄️
lunaria/files/es-ES.json Localization changed, will be marked as complete. 🔄️
lunaria/files/fr-FR.json Localization changed, will be marked as complete. 🔄️
lunaria/files/hi-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/hu-HU.json Localization changed, will be marked as complete. 🔄️
lunaria/files/id-ID.json Localization changed, will be marked as complete. 🔄️
lunaria/files/it-IT.json Localization changed, will be marked as complete. 🔄️
lunaria/files/ja-JP.json Localization changed, will be marked as complete. 🔄️
lunaria/files/kn-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/mr-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/nb-NO.json Localization changed, will be marked as complete. 🔄️
lunaria/files/ne-NP.json Localization changed, will be marked as complete. 🔄️
lunaria/files/pl-PL.json Localization changed, will be marked as complete. 🔄️
lunaria/files/pt-BR.json Localization changed, will be marked as complete. 🔄️
lunaria/files/ru-RU.json Localization changed, will be marked as complete. 🔄️
lunaria/files/ta-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/te-IN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/uk-UA.json Localization changed, will be marked as complete. 🔄️
lunaria/files/zh-CN.json Localization changed, will be marked as complete. 🔄️
lunaria/files/zh-TW.json Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@codecov
Copy link

codecov bot commented Feb 24, 2026

Codecov Report

❌ Patch coverage is 33.33333% with 2 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/pages/index.vue 33.33% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@graphieros
Copy link
Contributor

Since 4 packages are displayed, I think the original width of the wrapper could be restored (#1591 reduced max-w to wrap the long list)

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

Replaces the homepage static showcase with a dynamic "npmx picks" feature. Adds server/api/picks.get.ts (cached endpoint querying Algolia, sampling/filtering candidates, enriching with totalLikes, returning one pick per NPMX letter), server/utils/picks.ts (selection logic, thresholds, fallbacks), shared/types/picks.ts and re-export, updates app/pages/index.vue to fetch and render picks, adds unit and e2e tests and Algolia mock fixtures, renames i18n keys/schema from nav.popular_packages → nav.npmx_picks, and changes root route to ISR (3600s).

Possibly related PRs

Suggested reviewers

  • graphieros
  • danielroe
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The PR description clearly explains the motivation, implementation details, and algorithm for replacing hardcoded frameworks with rotating 'npmx' picks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/hourly-npmx-picks

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
modules/runtime/server/cache.ts (1)

249-257: Consider stabilising mock timestamps for deterministic fixtures.

Using one shared timestamp value in this block avoids per-item clock drift and keeps fixture output steadier.

♻️ Suggested tweak
   if (host === algoliaHost && pathname.endsWith('/query')) {
+    const now = new Date().toISOString()
     return {
       data: {
         hits: [
-          { name: 'nuxt', downloadsLast30Days: 500_000, modified: new Date().toISOString() },
-          { name: 'pnpm', downloadsLast30Days: 800_000, modified: new Date().toISOString() },
-          { name: 'express', downloadsLast30Days: 1_000_000, modified: new Date().toISOString() },
-          { name: 'minimatch', downloadsLast30Days: 600_000, modified: new Date().toISOString() },
-          { name: 'next', downloadsLast30Days: 700_000, modified: new Date().toISOString() },
-          { name: 'axios', downloadsLast30Days: 900_000, modified: new Date().toISOString() },
-          { name: 'remix', downloadsLast30Days: 400_000, modified: new Date().toISOString() },
-          { name: 'webpack', downloadsLast30Days: 750_000, modified: new Date().toISOString() },
+          { name: 'nuxt', downloadsLast30Days: 500_000, modified: now },
+          { name: 'pnpm', downloadsLast30Days: 800_000, modified: now },
+          { name: 'express', downloadsLast30Days: 1_000_000, modified: now },
+          { name: 'minimatch', downloadsLast30Days: 600_000, modified: now },
+          { name: 'next', downloadsLast30Days: 700_000, modified: now },
+          { name: 'axios', downloadsLast30Days: 900_000, modified: now },
+          { name: 'remix', downloadsLast30Days: 400_000, modified: now },
+          { name: 'webpack', downloadsLast30Days: 750_000, modified: now },
         ],
       },
     }
   }

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4ed4254 and 9e462b5.

📒 Files selected for processing (66)
  • app/pages/index.vue
  • i18n/locales/ar.json
  • i18n/locales/az-AZ.json
  • i18n/locales/bg-BG.json
  • i18n/locales/bn-IN.json
  • i18n/locales/cs-CZ.json
  • i18n/locales/de-DE.json
  • i18n/locales/en.json
  • i18n/locales/es.json
  • i18n/locales/fr-FR.json
  • i18n/locales/hi-IN.json
  • i18n/locales/hu-HU.json
  • i18n/locales/id-ID.json
  • i18n/locales/it-IT.json
  • i18n/locales/ja-JP.json
  • i18n/locales/kn-IN.json
  • i18n/locales/mr-IN.json
  • i18n/locales/nb-NO.json
  • i18n/locales/ne-NP.json
  • i18n/locales/pl-PL.json
  • i18n/locales/pt-BR.json
  • i18n/locales/ru-RU.json
  • i18n/locales/ta-IN.json
  • i18n/locales/te-IN.json
  • i18n/locales/uk-UA.json
  • i18n/locales/zh-CN.json
  • i18n/locales/zh-TW.json
  • i18n/schema.json
  • lunaria/files/ar-EG.json
  • lunaria/files/az-AZ.json
  • lunaria/files/bg-BG.json
  • lunaria/files/bn-IN.json
  • lunaria/files/cs-CZ.json
  • lunaria/files/de-DE.json
  • lunaria/files/en-GB.json
  • lunaria/files/en-US.json
  • lunaria/files/es-419.json
  • lunaria/files/es-ES.json
  • lunaria/files/fr-FR.json
  • lunaria/files/hi-IN.json
  • lunaria/files/hu-HU.json
  • lunaria/files/id-ID.json
  • lunaria/files/it-IT.json
  • lunaria/files/ja-JP.json
  • lunaria/files/kn-IN.json
  • lunaria/files/mr-IN.json
  • lunaria/files/nb-NO.json
  • lunaria/files/ne-NP.json
  • lunaria/files/pl-PL.json
  • lunaria/files/pt-BR.json
  • lunaria/files/ru-RU.json
  • lunaria/files/ta-IN.json
  • lunaria/files/te-IN.json
  • lunaria/files/uk-UA.json
  • lunaria/files/zh-CN.json
  • lunaria/files/zh-TW.json
  • modules/runtime/server/cache.ts
  • nuxt.config.ts
  • server/api/picks.get.ts
  • server/utils/atproto/utils/likes.ts
  • server/utils/picks.ts
  • shared/types/index.ts
  • shared/types/picks.ts
  • test/e2e/homepage-picks.spec.ts
  • test/fixtures/mock-routes.cjs
  • test/unit/server/utils/picks.spec.ts
💤 Files with no reviewable changes (47)
  • lunaria/files/pl-PL.json
  • i18n/locales/pl-PL.json
  • lunaria/files/zh-TW.json
  • lunaria/files/zh-CN.json
  • lunaria/files/bg-BG.json
  • lunaria/files/mr-IN.json
  • i18n/locales/pt-BR.json
  • i18n/locales/ar.json
  • i18n/locales/uk-UA.json
  • i18n/locales/es.json
  • i18n/locales/zh-CN.json
  • lunaria/files/bn-IN.json
  • lunaria/files/ne-NP.json
  • i18n/locales/ja-JP.json
  • i18n/locales/mr-IN.json
  • lunaria/files/az-AZ.json
  • i18n/locales/ta-IN.json
  • i18n/locales/bg-BG.json
  • i18n/locales/hi-IN.json
  • i18n/locales/cs-CZ.json
  • i18n/locales/de-DE.json
  • i18n/locales/nb-NO.json
  • lunaria/files/uk-UA.json
  • lunaria/files/ru-RU.json
  • i18n/locales/ru-RU.json
  • lunaria/files/te-IN.json
  • lunaria/files/id-ID.json
  • i18n/locales/te-IN.json
  • lunaria/files/ta-IN.json
  • i18n/locales/az-AZ.json
  • lunaria/files/es-ES.json
  • i18n/locales/bn-IN.json
  • lunaria/files/ja-JP.json
  • i18n/locales/ne-NP.json
  • lunaria/files/de-DE.json
  • lunaria/files/it-IT.json
  • lunaria/files/hu-HU.json
  • i18n/locales/it-IT.json
  • i18n/locales/hu-HU.json
  • lunaria/files/ar-EG.json
  • lunaria/files/cs-CZ.json
  • i18n/locales/zh-TW.json
  • lunaria/files/hi-IN.json
  • lunaria/files/nb-NO.json
  • i18n/locales/id-ID.json
  • lunaria/files/es-419.json
  • lunaria/files/pt-BR.json

const PICKS_MAX_AGE_MS = 60 * 60 * 1000

/** Pick `n` random items from `arr` (Fisher-Yates on a copy, sliced). */
function randomSample<T>(arr: T[], n: number): T[] {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't seem worth pulling in a dependency for this. AFAIK this is as simple as it gets without caring about cryptographically secure randomness or anything like that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But what if npmx.dev gets hacked because of those picks 🤯
JK of course 😆

Awesome PR btw 🚀

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
test/fixtures/mock-routes.cjs (1)

571-571: ⚠️ Potential issue | 🟠 Major

Algolia wildcard host pattern will not match correctly.

The pattern https://*-dsn.algolia.net/** contains a hostname wildcard (*), but urlMatchesPattern() at lines 601-608 only handles /** suffix patterns via startsWith. The * in the hostname won't be matched, so Algolia requests won't be mocked when using matchRoute().

🔧 Proposed fix to support wildcard patterns
 function urlMatchesPattern(url, pattern) {
-  // Convert "https://example.com/**" to a prefix check
-  if (pattern.endsWith('/**')) {
-    const prefix = pattern.slice(0, -2)
-    return url.startsWith(prefix)
+  // Support wildcard patterns (e.g. https://*-dsn.algolia.net/**)
+  if (pattern.includes('*')) {
+    const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '.*').replace(/\*/g, '[^/]*')
+    return new RegExp(`^${escaped}`).test(url)
   }
   return url === pattern
 }
🧹 Nitpick comments (2)
server/utils/picks.ts (1)

29-52: Selection logic is sound but consider defensive letterIndex handling.

The logic correctly handles empty pools via optional chaining and falls back to hardcoded values. However, if a candidate name were to somehow not contain the expected letter (edge case), indexOf would return -1.

Currently this is safe because:

  1. Candidates are filtered by name.toLowerCase().includes(letter) in the API handler
  2. Fallbacks are verified to contain their letters

For defensive coding, you might consider clamping to 0:

🛡️ Optional defensive fix
     picks.push({
       letter,
       name,
-      letterIndex: name.toLowerCase().indexOf(letter),
+      letterIndex: Math.max(0, name.toLowerCase().indexOf(letter)),
     })
server/api/picks.get.ts (1)

75-96: Type assertion on empty object is fragile.

The empty object {} is cast to Record<NpmxLetter, PickCandidate[]>, which bypasses type checking. While Promise.all ensures all letters are assigned before use, this pattern could mask issues if the code is refactored.

♻️ Safer initialisation with explicit typing
-    const candidatesByLetter = {} as Record<NpmxLetter, PickCandidate[]>
+    const candidatesByLetter: Partial<Record<NpmxLetter, PickCandidate[]>> = {}

     await Promise.all(
       NPMX_LETTERS.map(async letter => {
         // ... existing code ...
         candidatesByLetter[letter] = enriched
       }),
     )

-    const picks = selectPicks(candidatesByLetter)
+    const picks = selectPicks(candidatesByLetter as Record<NpmxLetter, PickCandidate[]>)

This makes the incomplete state explicit during construction, with the cast only at the point of use after all letters are guaranteed to be assigned.


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9e462b5 and f28fbd2.

📒 Files selected for processing (6)
  • modules/runtime/server/cache.ts
  • server/api/picks.get.ts
  • server/utils/picks.ts
  • test/e2e/homepage-picks.spec.ts
  • test/fixtures/mock-routes.cjs
  • test/unit/server/utils/picks.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • test/unit/server/utils/picks.spec.ts
  • test/e2e/homepage-picks.spec.ts
  • modules/runtime/server/cache.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9d39380 and 862a7a6.

📒 Files selected for processing (1)
  • test/fixtures/mock-routes.cjs

Comment on lines +527 to +539
function matchAlgoliaApi(_urlString) {
return json({
hits: [
{ name: 'nuxt', downloadsLast30Days: 500000 },
{ name: 'pnpm', downloadsLast30Days: 800000 },
{ name: 'express', downloadsLast30Days: 1000000 },
{ name: 'minimatch', downloadsLast30Days: 600000 },
{ name: 'next', downloadsLast30Days: 700000 },
{ name: 'axios', downloadsLast30Days: 900000 },
{ name: 'remix', downloadsLast30Days: 400000 },
{ name: 'webpack', downloadsLast30Days: 750000 },
],
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== 1) Fields in Algolia fixture ==="
cat test/fixtures/mock-routes.cjs | sed -n '527,539p'

echo
echo "=== 2) Full matchAlgoliaApi function context ==="
rg -A 20 "function matchAlgoliaApi" test/fixtures/mock-routes.cjs

echo
echo "=== 3) Fields consumed in picks.get.ts ==="
cat server/api/picks.get.ts

echo
echo "=== 4) Fields consumed in picks.ts ==="
cat server/utils/picks.ts

Repository: npmx-dev/npmx.dev

Length of output: 6160


Fixture lacks isDeprecated field required by picks filtering logic.

At lines 527–539, the matchAlgoliaApi mock provides only name and downloadsLast30Days, but the real picks implementation (picks.get.ts) explicitly requests and filters on isDeprecated:

  • Line 63: attributesToRetrieve: ['name', 'downloadsLast30Days', 'isDeprecated']
  • Lines 70–75: Filters out deprecated packages with if (hit.isDeprecated) return false

The fixture never exercises this deprecation-filtering path, leaving regression risk uncovered. All fixture entries will silently pass the deprecation check (as undefined is falsy) regardless of their intended deprecation status.

@serhalp serhalp requested a review from danielroe February 28, 2026 13:03

it('falls back to non-liked when no liked candidates exist', () => {
const candidates: Record<NpmxLetter, PickCandidate[]> = {
n: [{ name: 'nodemon', totalLikes: 2 }],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nitpick here: But if MIN_LIKES is equal to 2, then this test case seems misleading. I'm not sure if it would be cleaner, but maybe MIN_LIKES - 1 could make this test case more robust? Open for ideas 💡

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants